package de.pyranja.storage.core;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.collect.Multimaps.synchronizedListMultimap;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;

import javax.annotation.concurrent.ThreadSafe;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ListMultimap;
import com.google.common.io.ByteStreams;
import com.google.common.io.CountingInputStream;

import de.pyranja.storage.Blob;
import de.pyranja.storage.exception.BinaryStorageException;
import de.pyranja.storage.exception.BlobNotFoundException;
import de.pyranja.storage.support.HexUtil;

/**
 * Temporary-file based staging ground for binary data that is enqueued to be
 * persisted. Uses a hashing algorithm to generate the id and sizes the data.
 * 
 * @author Chris Borckholder
 */
@ThreadSafe
public class Staging {
	/* slf4j-logger */
	private final static Logger log = LoggerFactory.getLogger(Staging.class);

	public static final String TEMP_PREFIX = "staged_";
	public static final String TEMP_SUFFIX = ".blob";

	// root directory for staged files
	private final File root;
	// calculates hashes
	private final String digestAlgorithm;
	// keeps track of staged files
	private final ListMultimap<String, File> staged;

	/**
	 * Constructs a staging area for binary data
	 * @param root directory of staged files
	 * @param digester used to hash input data
	 */
	public Staging(final File root, final MessageDigest digester) {
		super();
		checkNotNull(digester,"must give a prototype MessageDigest");
		this.root = checkNotNull(root,"not a valid staging directory");
		checkArgument(root.isDirectory(),"path %s is not a directory",root.toString());
		this.digestAlgorithm = digester.getAlgorithm();
		this.staged = synchronizedListMultimap(ArrayListMultimap.<String, File> create());
	}

	/**
	 * Consumes a stream of binary data and prepares it to be persisted. That
	 * data will eventually be accessible through the returned {@link BlobID}.
	 * @param data binary data stream
	 * @return BlobID of the data
	 */
	public Blob putUp(final InputStream data) {
		log.trace("putting up a new staging file");
		checkNotNull(data, "cannot stage null stream");
		// create staging file
		File temp;
		try {
			temp = File.createTempFile(TEMP_PREFIX, TEMP_SUFFIX,root);
		} catch (IOException e) {
			throw new BinaryStorageException("failed to create staging file", e);
		}
		temp.deleteOnExit();
		// copy and hash
		Blob blob;
		try (FileOutputStream fout = new FileOutputStream(temp);
				CountingInputStream countIn = new CountingInputStream(data);
				DigestInputStream digestIn = new DigestInputStream(countIn, digester())
				) {
			ByteStreams.copy(digestIn, fout);
			fout.flush();
			byte[] hash = digestIn.getMessageDigest().digest();
			blob = new Blob(HexUtil.toHex(hash), countIn.getCount());
		} catch (IOException e) {
			throw new BinaryStorageException("failed to create staging file", e);
		}
		// record in staging map
		staged.put(blob.getId(), temp);
		log.trace("staged {} as temporary file {}", blob.getId(), temp);
		return blob;
	}

	/**
	 * Get a reference to a file that contains the data identified by the 
	 * {@link BlobID} if one is staged. 
	 * <p>By taking the file, the caller also takes responsibility for it. After 
	 * calling this method one less file will be staged for {@code id}.</p>
	 * @param id of the data
	 * @return File that contains the data
	 * @throws BlobNotFoundException if no file for {@code id} is available
	 */
	public File take(final String id) {
		log.trace("taking {}", id);
		checkNotNull(id, "null is not a valid BlobID");
		File file;
		// retrieve file handle
		final List<File> blobFiles = staged.get(id);
		synchronized (staged) { // must sync on map to allow concurrent access
			if (!blobFiles.isEmpty()) {
				file = blobFiles.remove(0);
			} else {
				throw new BlobNotFoundException(id);
			}
		}
		// hand off file
		log.trace("taken {} from {}", id, file);
		return file;
	}

	/**
	 * Frees a file that contains data identified by the {@link BlobID}. After
	 * calling this method one less file will be staged for {@code id}.
	 * @param id of data
	 * @return true if a file was freed, false if no file for {@code id} was 
	 * found.
	 */
	public boolean free(final String id) {
		log.trace("freeing {}", id);
		checkNotNull(id, "null is not a valid BlobID");
		File file;
		// retrieve a file for blob id
		final List<File> blobFiles = staged.get(id);
		synchronized (staged) { // must sync on map to allow concurrent access
			if (!blobFiles.isEmpty()) {
				file = blobFiles.remove(0);
			} else { // allowed
				log.debug("attempted to free {}, but no matching file found",
						id);
				return false;
			}
		} // end sync
		// delete if possible
		boolean deleted = false;
		try {
			Files.delete(file.toPath());
			deleted = true;
		} catch (IOException e) {
			log.warn("deleting staged file {} for blob {} failed",
					new Object[] { file, id }, e);
		}
		log.trace("freed {} from {} that was removed? {}", new Object[] { id, file, deleted });
		return deleted;
	}

	/*
	 * produce a MessageDigest from set algorithm name
	 */
	private MessageDigest digester() {
		try {
			return MessageDigest.getInstance(digestAlgorithm);
		} catch (NoSuchAlgorithmException e) {
			log.error("instantiating message digester {} failed", digestAlgorithm, e);
			throw new AssertionError(
					"Instantiating MessageDigest should not fail", e);
		}
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see java.lang.Object#toString()
	 */
	@Override
	public String toString() {
		return "FileStaging [root=" + root + ", digestAlgorithm="
				+ digestAlgorithm + "]";
	}
}
